// Copyright (c) HashiCorp, Inc
// SPDX-License-Identifier: MPL-2.0

// uses the functions.json file to generate the bindings for CDKTF

import fs from "fs/promises";
import * as path from "path";
import { AttributeType } from "@cdktf/commons";
import generate from "@babel/generator";
import template from "@babel/template";
import { parseExpression } from "@babel/parser";
import * as t from "@babel/types";
import prettier from "prettier";
import { FUNCTIONS_METADATA_FILE } from "./constants";

const ts = template({ plugins: [["typescript", {}]] });

const FUNCTION_BINDINGS_OUTPUT_FILE = path.resolve(
  __dirname,
  "..",
  "..",
  "..",
  "packages",
  "cdktf",
  "lib",
  "functions",
  "terraform-functions.generated.ts",
);

const FUNCTIONS_MAP_OUTPUT_FILE = path.resolve(
  __dirname,
  "..",
  "..",
  "..",
  "packages",
  "@cdktf",
  "hcl2cdk",
  "lib",
  "function-bindings",
  "functions.generated.ts",
);

type Parameter = { name: string; type: AttributeType };
type FunctionSignature = {
  description: string;
  return_type: AttributeType;
  parameters: Parameter[];
  variadic_parameter: Parameter;
};
type MappedParameter = {
  name: string;
  mapper: string;
  tsParam: t.Identifier;
  docstringType: string;
};

const IMPORTS = ts`
import {
  anyValue,
  asAny,
  asBoolean,
  asList,
  asNumber,
  asString,
  listOf,
  mapValue,
  numericValue,
  stringValue,
  terraformFunction,
  variadic,
} from "./helpers";
`() as t.Statement;
t.addComment(
  IMPORTS,
  "leading",
  `\n * This file is generated by tools/generate-function-bindings.
 * To update this file execute 'yarn run generate-function-bindings' in the root of the repository
 `,
  false,
);

// these are overwritten in terraform-functions.ts
const INTERNAL_METHODS = ["join", "bcrypt", "range", "lookup"];

// the resulting file is used in the cdktf core library
async function generateFunctionBindings() {
  const file = path.join(__dirname, FUNCTIONS_METADATA_FILE);
  const json = JSON.parse((await fs.readFile(file)).toString())
    .function_signatures as {
    [name: string]: FunctionSignature;
  };

  const staticMethods = Object.entries(json).map(([name, signature]) =>
    renderStaticMethod(name, signature),
  );

  const fnClass = t.exportNamedDeclaration(
    t.classDeclaration(
      t.identifier("FnGenerated"),
      null,
      t.classBody(staticMethods),
    ),
  );
  t.addComment(
    fnClass,
    "leading",
    " eslint-disable-next-line jsdoc/require-jsdoc",
    true,
  );

  const program = t.program([IMPORTS, fnClass]);

  const code = prettier.format(
    // adding comments unrelated to code doesn't work with an AST
    `// Copyright (c) HashiCorp, Inc
// SPDX-License-Identifier: MPL-2.0
  ${generate(program as any).code}`,
    {
      parser: "babel",
    },
  );

  await fs.writeFile(FUNCTION_BINDINGS_OUTPUT_FILE, code);
}

// the resulting file is used in convert
async function generateFunctionsMap() {
  const file = path.join(__dirname, FUNCTIONS_METADATA_FILE);
  const json = JSON.parse((await fs.readFile(file)).toString())
    .function_signatures as {
    [name: string]: FunctionSignature;
  };

  const properties: t.ObjectProperty[] = [];

  Object.entries(json).forEach(([functionName, signature]) => {
    // don't include internal ones in the mappings, they will get a manual override in functions.ts
    if (INTERNAL_METHODS.includes(functionName)) return;

    const meta = {
      name: functionName === "length" ? "lengthOf" : functionName,
      returnType: signature.return_type,
      parameters: [
        ...(signature.parameters
          ? signature.parameters.map((param) => ({ type: param.type }))
          : []),
        ...(signature.variadic_parameter
          ? [{ type: signature.variadic_parameter.type, variadic: true }]
          : []),
      ],
    };

    const functionMeta = parseExpression(JSON.stringify(meta));
    properties.push(t.objectProperty(t.identifier(functionName), functionMeta));
  });

  const fnMap = t.exportNamedDeclaration(
    t.variableDeclaration("const", [
      t.variableDeclarator(
        t.identifier("functionsMapGenerated"),
        t.objectExpression(properties),
      ),
    ]),
  );

  const program = t.program([fnMap]);

  const code = prettier.format(
    // adding comments unrelated to code doesn't work with an AST
    `// Copyright (c) HashiCorp, Inc
// SPDX-License-Identifier: MPL-2.0

// This file is generated by tools/generate-function-bindings.
// To update this file execute 'yarn run generate-function-bindings' in the root of the repository
  ${generate(program as any).code}`,
    {
      parser: "babel",
    },
  );

  await fs.writeFile(FUNCTIONS_MAP_OUTPUT_FILE, code);
}

type ReturnType = "asNumber" | "asString" | "asBoolean" | "asAny" | "asList";
function mapReturnType(
  returnType: AttributeType,
  functionName: string,
): ReturnType {
  switch (returnType) {
    case "number":
      return "asNumber";
    case "string":
      return "asString";
    case "bool":
      return "asBoolean";
    case "dynamic":
      return "asAny"; // TODO: this was no wrapping but now is asAny (BREAKING, as it used to return IResolvable for some functions but now returns any)
  }
  if (
    Array.isArray(returnType) &&
    (returnType[0] === "list" || returnType[0] === "set")
  ) {
    return "asList";
  }
  if (Array.isArray(returnType) && returnType[0] === "map") {
    return "asAny";
  }
  throw new Error(
    `Function ${functionName} has unsupported return type: ${JSON.stringify(
      returnType,
    )}`,
  );
}

function mapParameter(p: Parameter) {
  let name = p.name;
  if (name === "default") name = "defaultValue"; // keyword in TypeScript
  if (name === "string") name = "str"; // causes issue is Go

  const parseType = (
    type: AttributeType,
  ): { mapper: string; tsType: t.TSType; docstringType: string } => {
    if (type === "number") {
      return {
        mapper: "numericValue",
        tsType: t.tsNumberKeyword(),
        docstringType: "number",
      };
    }
    if (type === "string") {
      return {
        mapper: "stringValue",
        tsType: t.tsStringKeyword(),
        docstringType: "string",
      };
    }
    if (type === "bool") {
      return {
        mapper: "anyValue",
        tsType: t.tsAnyKeyword(), // we can't use booleans here as we don't have boolean tokens but need to support token values too
        docstringType: "any",
      };
    }
    if (type === "dynamic") {
      return {
        mapper: "anyValue",
        tsType: t.tsAnyKeyword(),
        docstringType: "any",
      };
    }
    if (Array.isArray(type) && (type[0] === "list" || type[0] === "set")) {
      const child = parseType(type[1]);

      // We use anyValue for string lists as we don't validate
      // the individual strings in a list to make using these
      // functions more graceful
      if (type[1] === "string") {
        child.mapper = "anyValue";
      }

      return {
        mapper: `listOf(${child.mapper})`,
        tsType: t.tsArrayType(child.tsType),
        docstringType: `Array<${child.docstringType}>`,
      };
    }
    if (Array.isArray(type) && type[0] === "map") {
      const child = parseType(type[1]);
      return {
        mapper: "mapValue",
        tsType: t.tsAnyKeyword(),
        docstringType: "Object<string, " + child.docstringType + ">",
      };
    }
    throw new Error(
      `Function ${name} has parameter ${
        p.name
      } with unsupported type ${JSON.stringify(p.type)}`,
    );
  };

  const { docstringType, mapper, tsType } = parseType(p.type);

  const tsParam = t.identifier(name);
  tsParam.typeAnnotation = t.tsTypeAnnotation(tsType);

  return { name, mapper, tsParam, docstringType };
}

function renderStaticMethod(
  name: string,
  signature: FunctionSignature,
): t.ClassMethod {
  const returnType = mapReturnType(signature.return_type, name);

  const parameters: MappedParameter[] = (signature.parameters || []).map(
    mapParameter,
  );

  if (signature.variadic_parameter) {
    const p = mapParameter(signature.variadic_parameter);
    p.tsParam.typeAnnotation = t.tsTypeAnnotation(
      t.tsArrayType(
        (p.tsParam.typeAnnotation as t.TSTypeAnnotation).typeAnnotation,
      ),
    );
    parameters.push({
      name: p.name,
      docstringType: `Array<${p.docstringType}>`,
      mapper: `variadic(${p.mapper})`,
      tsParam: p.tsParam,
    });
  }

  // we need a space (Prettier will remove it) as somehow ts`` works in weird ways when
  // passing an empty (or falsy value in the template string)
  const argValueMappers: string =
    parameters.map((p) => p.mapper).join(",") || " ";
  const argNames: string = parameters.map((p) => p.name).join(",");
  const params: any[] = parameters.map((p) => p.tsParam);

  const body = ts`
  return ${returnType}(terraformFunction("${name}", [${argValueMappers}])(${argNames}));
  `();

  const isInternal = INTERNAL_METHODS.includes(name);

  let sanitizedFunctionName = name === "length" ? "lengthOf" : name;
  if (isInternal) {
    sanitizedFunctionName = `_${sanitizedFunctionName}`;
  }

  const method = t.classMethod(
    "method",
    t.stringLiteral(sanitizedFunctionName),
    params,
    t.blockStatement(Array.isArray(body) ? body : [body]),
    false, // computed
    true, // static
  );

  // comment with docstring for method
  const descriptionWithLink = signature.description.replace(
    `\`${name}\``,
    `{@link https://developer.hashicorp.com/terraform/language/functions/${name} ${name}}`,
  );
  t.addComment(
    method,
    "leading",
    [
      "*",
      ...(isInternal ? ["* @internal"] : []),
      `* ${descriptionWithLink}`,
      ...parameters.map((p) => ` * @param {${p.docstringType}} ${p.name}`),
      "",
    ].join("\n"),
  );

  return method;
}

(async function main() {
  await generateFunctionBindings();
  await generateFunctionsMap();
})();
